AWS CDKがデプロイするリソース名はどうやって決まるのか
はじめに
今回はAWS CDKがリソースをデプロイする際にリソース名が最終的にどう決まるのか、調査した内容を元に解説します。自分自身どうやって決まるのか間違って理解していた部分もあるので半分記録として残します。
また度々CDKのcore部分はロジックは変わらなくても、ソースのディレクトリが変更されたりするので、2024年9月の段階でどのような構成になるのかを改めて確認してみます。
パターンの紹介
大きな区分けとして、リソース名を指定する場合と指定しない場合があります。それぞれを分けて説明していきます。
パターン1:リソース名を指定する場合
これは以下のようにCDKのConstructに対して名前を指定する場合です。こちらの場合は、単純で指定した名前のリソースがAWSアカウント上に作成されます。
const bucket = new s3.Bucket(this, "MyBucket", {
bucketName: "hoge-fuga-piyo-123456789012"
});
上記で作成する場合は、リソース名の重複が発生しやすくなるのでリソース名の付け方に工夫が必要になります。なので、公式としては推奨はされていないです。ただ個人的には、運用でコンソールを見る時に可読性や複雑性を下げるため度々使いたくなります。
パターン2:リソース名を指定しない場合
ここからはメインとなるリソース名を指定しない場合を紹介します。全体的には以下のようなロジックでリソース名は決定していきます。
- CDKがConstructに指定したIDをベースに論理IDを生成し、CloudFormation(Cfn)のテンプレートに書き込み
- Cfnがテンプレートをベースにリソースの作成を開始。各AWSサービスごとに独自のロジックでリソース名を決定し、リソースを作成
上記の流れの中で大まかにCDK内のロジックで論理IDを決める部分と、Cfn+各AWSサービスごとのロジックでリソース名を決める2段階でリソース名は決まります。前半と後半に分けて何パターンかサンプルを用意して紹介します。
CDKによる論理IDの生成
CDKはいくつかの条件で論理IDを生成します。大まかにはスタック直下でConstructを定義するTop-Level Constructの場合とそれ以外の場合。L1 Constructを使う場合、L2 Constructを使う場合で出力が異なります。まずはTop-Level ConstructでL1 Constructの場合を考えます。単純にConstructに設定したIDがそのままテンプレートに論理ID MyBucket
,MyTopic
として記載されます。以下はStack直下でS3とSNSのConstructを使った例です。
論理IDの詳細はこちらご確認ください。
const bucket = new s3.CfnBucket(this, "MyBucket", {});
const tp = new sns.CfnTopic(this,"MyTopic",{});
生成されたテンプレート(関連部分のみ)
{
"Resources": {
"MyBucket": {
"Type": "AWS::S3::Bucket",
...
},
"MyTopic": {
"Type": "AWS::SNS::Topic",
...
},
Top-Level ConstructかつL1 Construct(厳密にはcomponents
が1つの場合)は以下のCDK内部の以下のロジックで、設定したIDがそのまま論理IDとなります。
次はTop-Level ConstructかつL2 Constructの場合です。以下のようなコードでテンプレートを生成してみます。
const bucket2 = new s3.Bucket(this, "MyBucket", {});
const tp2 = new sns.Topic(this,`MyTopic`,{})
生成されたテンプレート(関連部分のみ)
{
"Resources": {
"MyBucketF68F3FF0": {
"Type": "AWS::S3::Bucket",
...
},
"MyTopic86869434": {
"Type": "AWS::SNS::Topic",
...
},
上記のように、Constructの第二引数に設定したIDの値にプラスして、suffixが付与されています。このsuffixは、CDKの以下のロジック部分で生成されています。
参考
上記参考のように、エラーの早期リターンやTop-Level Construct用の処理、環境変数などを抜いて抜粋すると以下のロジックになっています。ロジックの詳細はここでは省くので、気になった際は上記の参考記事とコードをご参照ください。
const hash = pathHash(components);
const human = removeDupes(components)
.filter(x => x !== HIDDEN_FROM_HUMAN_ID)
.map(removeNonAlphanumeric)
.join('')
.slice(0, MAX_HUMAN_LEN);
return human + hash;
上記のロジックで、MyBucketF68F3FF0
という論理IDが生成されています。実際にどんな値が入力されているのか、CDKから一部ロジックのみ抜粋して検証してみます。上記のロジックに合わせて使用しているメソッドや環境変数を入れてみます。cdkのテンプレート生成全体のロジックが完全には分かっていないため、Resource
の部分は逆算して必要なだったコードを抜粋しています。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { aws_s3 as s3 } from 'aws-cdk-lib';
import * as crypto from 'crypto';
/**
* 人が読める部分には表示されないが、ハッシュ値の計算に利用される
* Ref: https://github.com/aws/aws-cdk/blob/7966f8d48c4bff26beb22856d289f9d0c7e7081d/packages/%40aws-cdk/core/lib/private/uniqueid.ts#L10
*/
const HIDDEN_FROM_HUMAN_ID = 'Resource';
const HASH_LEN = 8;
const MAX_HUMAN_LEN = 240; // max ID len is 255
const PATH_SEP = '/';
export class ConstructIdSampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const bucket2 = new s3.Bucket(this, "MyBucket", {});
const components = [bucket2.node.id, HIDDEN_FROM_HUMAN_ID]; // 実体: "MyBucket","Resource"
const hash = pathHash(components);
const human = removeDupes(components)
.filter(x => x !== HIDDEN_FROM_HUMAN_ID)
.map(removeNonAlphanumeric)
.join('')
.slice(0, MAX_HUMAN_LEN);
new cdk.CfnOutput(this, 'ResourceName', {
value: human + hash,
});
}
}
function pathHash(path: string[]): string {
const md5 = crypto.createHash('md5').update(path.join(PATH_SEP)).digest('hex');
return md5.slice(0, HASH_LEN).toUpperCase();
}
function removeDupes(path: string[]): string[] {
const ret = new Array<string>();
for (const component of path) {
if (ret.length === 0 || !ret[ret.length - 1].endsWith(component)) {
ret.push(component);
}
}
return ret;
}
function removeNonAlphanumeric(s: string) {
return s.replace(/[^A-Za-z0-9]/g, '');
}
上記のコードを実行した結果、テンプレートのOutputsの値がMyBucketF68F3FF0
となります。少なくともStack直下のConstructは上記のロジックで、Constructに渡したIDをベースにCfnの論理IDが決まります。
{
"Resources": {
"MyBucketF68F3FF0": {
"Type": "AWS::S3::Bucket",
...
...
"Outputs": {
"ResourceName": {
"Value": "MyBucketF68F3FF0"
}
},
...
他にもSNSトピック、DynamoDB Table、SQSのキューなどを確認したところ、同様に上記のロジックで論理IDが決まることが確認できました。
結論として、CDKではL1 ConstructだとConstruct生成時に与えたIDがそのまま使われる。L2 Constructだと、Construct生成時に与えたIDとID+固定値のResource
をベースにしたハッシュ値、それぞれを結合した文字列がリソース名となります。
20240916 追記
正確ではない表現だったので訂正します。CDKの内部ロジックとしては、L1/L2でハッシュ値をつけるか判断しているのではなく、あくまでConstructのLevelに応じてハッシュ値をつけるか判断しています。
Stack直下にL1 Constructを実装した場合、Top-Level Constructになるので、上記のように論理IDにハッシュ値はつきません。ただ以下のようにConstructを独自に実装して、Stackから独自Construct→S3のConstructを呼び出した場合、L1でも以下のようにハッシュ値が付与されます。
export class SampleConstruct extends Construct {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id);
const bucket = new s3.Bucket(this, "MyBucketX", {});
const cfnBucket = new s3.CfnBucket(this, "MyCfnBucketX", {});
const components = [this.node.id, cfnBucket.node.id];
// (中略)先ほどと同じCDKのロジック
new cdk.CfnOutput(this, 'ConstructResourceName', {
value: human + hash,
});
}
}
出力されたテンプレート
"SampleConstructMyBucketX5AF69B3F": {
"Type": "AWS::S3::Bucket",
...
},
"SampleConstructMyCfnBucketX47A6EB3F": {
"Type": "AWS::S3::Bucket",
...
},
"Outputs": {
"SampleConstructConstructResourceName41418625": {
"Value": "SampleConstructMyCfnBucketX47A6EB3F"
},
...
なので、Constructの内部でL1を呼び出す場合はハッシュ値が追記されます。このハッシュ値は、親の階層のConstructを含めて計算されます。
またL2 Constructは、生成途中で/Resourceが付与されるためTop-Level Constructにはならないので、ロジックの通りIDを元にハッシュ値が付与されます。これはテンプレート生成時のMetadataのaws:cdk:path
を見るとわかりやすいです。
Tomookaさん訂正のコメントいただきありがとうございますmm
追加として、Constructに渡すIDにDefault
を指定すると、Construct Tree内の階層1つがハッシュ計算から除外されます。詳細は以下をご確認ください。
最終的にCDKからはCfnのテンプレートに対して、論理IDを記載してCfnに渡します。これが実際に付くリソース名の元になります。
CloudFormationによるリソース名の生成
今度はテンプレートの内容を元にCfnがどんなリソースを生成するのか確認します。こちらのロジックは公開されていないので、実際にデプロイして試してみます。今回は、S3バケット、SNSトピック、WAFのWeb ACLで試してみます。
スタック名はConstructIdSampleStack
で以下のコードをデプロイしてみます。
const bucket2 = new s3.Bucket(this, "MyBucket", {});
const tp2 = new sns.Topic(this,"MyTopic",{});
const webAcl = new wafv2.CfnWebACL(this, `WebAcl`, {
defaultAction: { allow: {} },
scope: 'REGIONAL',
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'WebAcl',
sampledRequestsEnabled: true,
},
rules: []
});
new cdk.CfnOutput(this, 'S3BucketArn', {
value: bucket2.bucketArn,
description: "S3BucketArn"
});
new cdk.CfnOutput(this, 'SnsArn', {
value: tp2.topicArn,
});
new cdk.CfnOutput(this, 'WebAclArn', {
value: webAcl.attrArn,
});
CloudFormationのテンプレート内の論理IDは以下のようになっています。
{
"Resources": {
"MyBucketF68F3FF0": {
"Type": "AWS::S3::Bucket",
...
},
"MyTopic86869434": {
"Type": "AWS::SNS::Topic",
...
},
"WebAcl": {
"Type": "AWS::WAFv2::WebACL",
...
出力結果は以下のようになりました。テンプレートで指定した論理IDにプラスして、スタック名やランダムな値などが追加されています。(ランダムな値は一部値を変えています)
ConstructIdSampleStack.S3BucketArn = arn:aws:s3:::constructidsamplestack-mybucketf68f3ff0-n8jdmj3ocqz8
ConstructIdSampleStack.SnsArn = arn:aws:sns:ap-northeast-1:123456789012:ConstructIdSampleStack-MyTopic86869434-AO780DANYO90
ConstructIdSampleStack.WebAclArn = arn:aws:wafv2:ap-northeast-1:123456789012:regional/webacl/WebAcl-bLhJjT3HpSQS/12345678-AAAA-XXXX-YYYY-ZZZZZZZZZZZZ
上記からCfnが生成するリソース名はテンプレートの論理IDをベースに、S3やSNSのようにスタック名が付与されたり、suffixにハッシュ値が付与されています。またWAFを見るとスタック名は付与されず、suffixにランダムな値が付与されることがわかります。
上記からCfnのリソース名生成は、統一されたロジックではなく各AWSサービスごとに委ねられていて、スタック名が利用されるものもあればされないものもあるということが推測できます。
またWAFに関しては、同じアカウント/スタック名/テンプレートの論理IDでも実行日によってsuffixが変わっていたので、時間など別の要素も関係するようです。
余談:Cfnを使ったリソース名生成はロジックが昔と変わっている?
Cfnと各AWSサービスでリソース名が決まる部分は、実行する時期によって新規リソース名の生成ロジックが若干異なるのでは?と思っています。2021年ぐらいにCDKでWAFのWeb ACLを作成した際Cfnの論理IDがそのまま使われていて、複数人で1つのアカウントを使っている場合リソース重複でデプロイが落ちたのですが、最近はスタックか何かをベースにしたランダムな値がsuffixに付くようです。
同じようにAuroraのクラスター名も昔は、Cfnの論理IDがそのまま使われていたものがスタック名を使うようになっていた気がします。Cfnと各AWSサービスのリソース名決定のロジックで、WAFに関してはこちらとこちらのGit Blameを見ると変わってなさそうでした。
またCfn CLIを拡張する場合のロジックはここに公開されていますが全容が把握できていなく、自分の記憶しかないですがもし同じような記憶ある方いればXまでコメント等ください。
上記の重複エラーがあったので、昔はConstructのIDを動的に指定することを推してたのですが、もう今はAWS側が考慮してくれているので、考慮が要らないサービスが増えていそうです。
所感
たまたまリソース名生成の流れを調べる機会があったので、ついでに確認して記事にしてみました。もう少し深く調べることも出来そうですが、これだけでも多少価値があると思ったのでどなたかの参考になれば幸いです。